media_pp\elements\sink\muxer/mp4_muxer.rs
1use std::{
2 path::Path,
3 sync::{Arc, Mutex},
4};
5
6use crate::pp_log::{PpLog, pp_error};
7use ffmpeg_next as ffmpeg;
8use thiserror::Error as ThisError;
9
10use crate::{
11 buffer::MediaBuffer,
12 control::ControlMsg,
13 element::{Element, ElementType, Sink, element_pp_log},
14 error::Result,
15};
16
17/// Errors specific to `Mp4Muxer`. Converts into the crate-wide `Error` via
18/// `?` (see [`crate::error::Error`]).
19#[derive(Debug, ThisError)]
20pub enum Mp4MuxerError {
21 #[error("Mp4Muxer stream sinks only accept Packet or Eos buffers, got {0}")]
22 UnsupportedBuffer(&'static str),
23
24 #[error("ffmpeg error: {0}")]
25 Ffmpeg(#[from] ffmpeg::Error),
26}
27
28/// One track registered via [`Mp4Muxer::add_stream`], waiting for
29/// [`Mp4Muxer::open`] to turn it into a real [`Mp4MuxerStreamSink`] — its
30/// `name` becomes that sink's own [`Element::name`]/`pp_log` identity, and
31/// `input_time_base` is what every `Packet` it receives already carries
32/// `pts`/`dts` in (the same one its upstream encoder was opened with).
33struct PendingStream {
34 name: Arc<str>,
35 input_time_base: ffmpeg::Rational,
36}
37
38/// Builds an MP4 (or any other container ffmpeg infers from `path`'s
39/// extension) with one or more tracks, then opens it into one [`Sink`] per
40/// track. Two-phase on purpose: a container's header has to describe
41/// every stream's codec parameters up front — `avformat_write_header`
42/// can't run until every [`Mp4Muxer::add_stream`] this file will ever hold
43/// has already happened — so there's no way to make this a single
44/// long-lived `Sink` that tracks attach to one at a time as their encoders
45/// come online (contrast [`crate::elements::AudioMixer`], whose inputs
46/// *can* attach at any time — it has no "known shape before the first
47/// byte" constraint the way a container header does).
48///
49/// ```ignore
50/// let mut muxer = Mp4Muxer::create("out.mp4")?;
51/// muxer.add_stream("video", video_encoder.parameters(), video_time_base)?;
52/// muxer.add_stream("audio", audio_encoder.parameters(), audio_time_base)?;
53/// let mut sinks = muxer.open()?; // writes the header
54/// let audio_sink = sinks.pop().unwrap();
55/// let video_sink = sinks.pop().unwrap();
56/// ```
57pub struct Mp4Muxer {
58 output: ffmpeg::format::context::Output,
59 streams: Vec<PendingStream>,
60}
61
62impl Mp4Muxer {
63 /// Allocates the output file. No header is written yet — nothing is on
64 /// disk in a readable shape until [`Mp4Muxer::open`] runs.
65 pub fn create(path: impl AsRef<Path>) -> Result<Self> {
66 let output = ffmpeg::format::output(&path).map_err(Mp4MuxerError::from)?;
67 Ok(Self {
68 output,
69 streams: Vec::new(),
70 })
71 }
72
73 /// Registers one more track this file will hold. `parameters`/
74 /// `time_base` describe it — typically
75 /// [`crate::elements::SwEncoder::parameters`]/the same `time_base`
76 /// passed to its own `SwEncoderOptions` (or the
77 /// [`crate::elements::SwAudioEncoder`] equivalents). `name` becomes
78 /// this track's own [`Element::name`]/`pp_log` identity once
79 /// [`Mp4Muxer::open`] turns it into a `Sink` — pick something that
80 /// tells multiple tracks apart in logs/[`crate::bus::BusEvent`]s,
81 /// e.g. `"video"`/`"audio"`.
82 ///
83 /// Add streams in the same order the caller will treat
84 /// [`Mp4Muxer::open`]'s returned `Vec` — index 0 is whichever stream
85 /// was added first, and so on.
86 pub fn add_stream(
87 &mut self,
88 name: impl Into<String>,
89 parameters: ffmpeg::codec::Parameters,
90 time_base: ffmpeg::Rational,
91 ) -> Result<()> {
92 let mut stream = self
93 .output
94 .add_stream(parameters.id())
95 .map_err(Mp4MuxerError::from)?;
96 stream.set_time_base(time_base);
97 stream.set_parameters(parameters);
98 self.streams.push(PendingStream {
99 name: name.into().into(),
100 input_time_base: time_base,
101 });
102 Ok(())
103 }
104
105 /// Writes the container header — every [`Mp4Muxer::add_stream`] call
106 /// this file will ever get must already have happened — and returns
107 /// one [`Sink`] per track, in the order [`Mp4Muxer::add_stream`] added
108 /// them.
109 ///
110 /// All returned `Sink`s write into the same underlying file behind a
111 /// shared lock: packets from independently-threaded branches (e.g. a
112 /// video encode chain and an audio encode chain, each on their own
113 /// [`crate::queue::Queue`]) can arrive concurrently, and neither
114 /// `av_interleaved_write_frame` nor `av_write_trailer` is safe to call
115 /// from multiple threads against the same file at once. They also
116 /// share one trailer: it's written once every track has reported
117 /// itself done — via `Eos` *or* [`ControlMsg::Stop`], either meaning
118 /// "this track is finished" rather than "abandon the whole file" —
119 /// not on whichever track finishes first, which would silently
120 /// truncate whatever the other track(s) still had left to write. A
121 /// single-track file (e.g. `screen_record`/`audio_record`) degenerates
122 /// to finalizing on that one track's own `Eos`/`Stop`, same as before
123 /// this type supported more than one.
124 ///
125 /// A caller driving multiple tracks from independent
126 /// [`crate::pipeline::Pipeline`]s (today's architecture: one
127 /// `SourceElement` per pipeline, so a live video capture and a live
128 /// audio capture are necessarily two separate pipelines) is
129 /// responsible for stopping all of them — the file's trailer only
130 /// gets written once every track has actually reported done, so
131 /// stopping only one pipeline while another keeps running leaves the
132 /// file un-finalized (and unplayable) until the rest catch up too.
133 pub fn open(mut self) -> Result<Vec<Box<dyn Sink>>> {
134 self.output.write_header().map_err(Mp4MuxerError::from)?;
135 let total = self.streams.len();
136 let shared = Arc::new(Mp4MuxerShared {
137 state: Mutex::new(MuxerState {
138 output: self.output,
139 done: 0,
140 finished: false,
141 }),
142 total,
143 });
144 Ok(self
145 .streams
146 .into_iter()
147 .enumerate()
148 .map(|(index, stream)| -> Box<dyn Sink> {
149 Box::new(Mp4MuxerStreamSink {
150 pp_log: element_pp_log(ElementType::Mp4Muxer, &stream.name, None),
151 name: stream.name,
152 shared: shared.clone(),
153 stream_index: index,
154 input_time_base: stream.input_time_base,
155 done: false,
156 })
157 })
158 .collect())
159 }
160}
161
162struct MuxerState {
163 output: ffmpeg::format::context::Output,
164 /// How many tracks have reported themselves finished (`Eos` or
165 /// `Stop`) — the trailer is written once this reaches
166 /// [`Mp4MuxerShared::total`], not on the first one (see
167 /// [`Mp4Muxer::open`]'s own docs for why).
168 done: usize,
169 /// Set once the trailer has been written. Each
170 /// [`Mp4MuxerStreamSink`]'s own `done` flag already prevents
171 /// double-counting *that* track's contribution to `done`; this
172 /// additionally guards [`Mp4MuxerShared::write_packet`] against
173 /// writing into a file whose trailer has already closed it.
174 finished: bool,
175}
176
177/// Shared between every [`Mp4MuxerStreamSink`] [`Mp4Muxer::open`] hands
178/// out for the same file — one lock around the whole
179/// [`ffmpeg::format::context::Output`] so concurrent tracks never
180/// interleave two writes against it (see [`Mp4Muxer::open`]'s own docs).
181struct Mp4MuxerShared {
182 state: Mutex<MuxerState>,
183 total: usize,
184}
185
186impl Mp4MuxerShared {
187 fn write_packet(
188 &self,
189 stream_index: usize,
190 input_time_base: ffmpeg::Rational,
191 packet: &ffmpeg::Packet,
192 ) -> Result<()> {
193 let mut state = self.state.lock().unwrap();
194 if state.finished {
195 return Ok(());
196 }
197 // Cloned, not mutated in place — `Arc<Packet>` may be shared with
198 // another branch (e.g. a `PacketCounter` off the same `Tee`),
199 // which must not see this stream's `set_stream`/rescaled
200 // timestamps.
201 let mut packet = packet.clone();
202 let output_time_base = state
203 .output
204 .stream(stream_index)
205 .expect("stream was added in Mp4Muxer::add_stream")
206 .time_base();
207 packet.rescale_ts(input_time_base, output_time_base);
208 packet.set_stream(stream_index);
209 packet.set_position(-1);
210 packet
211 .write_interleaved(&mut state.output)
212 .map_err(Mp4MuxerError::from)?;
213 Ok(())
214 }
215
216 /// One track reporting itself done (`Eos` or `Stop`) — writes the
217 /// trailer exactly once, only once every track has called this.
218 fn finish_track(&self) -> Result<()> {
219 let mut state = self.state.lock().unwrap();
220 state.done += 1;
221 if state.finished || state.done < self.total {
222 return Ok(());
223 }
224 state.finished = true;
225 state.output.write_trailer().map_err(Mp4MuxerError::from)?;
226 Ok(())
227 }
228}
229
230/// One track's own [`Sink`] — a lightweight handle sharing a
231/// `Mp4MuxerShared` with every other track [`Mp4Muxer::open`] returned
232/// alongside it. See [`Mp4Muxer::open`]'s own docs for the
233/// finalize-once-every-track-is-done contract this relies on.
234pub struct Mp4MuxerStreamSink {
235 pp_log: PpLog,
236 name: Arc<str>,
237 shared: Arc<Mp4MuxerShared>,
238 stream_index: usize,
239 input_time_base: ffmpeg::Rational,
240 /// Set once this sink has contributed to
241 /// [`Mp4MuxerShared::finish_track`] — guards against double-counting
242 /// if both a natural `Eos` and a later `Stop` arrive for the same
243 /// track.
244 done: bool,
245}
246
247impl Mp4MuxerStreamSink {
248 fn finish(&mut self) -> Result<()> {
249 if self.done {
250 return Ok(());
251 }
252 self.done = true;
253 self.shared
254 .finish_track()
255 .inspect_err(|error| pp_error!(self, "write_trailer failed: {error}"))
256 }
257}
258
259impl Element for Mp4MuxerStreamSink {
260 fn name(&self) -> Arc<str> {
261 self.name.clone()
262 }
263
264 fn element_type(&self) -> ElementType {
265 ElementType::Mp4Muxer
266 }
267
268 fn pp_log(&self) -> &PpLog {
269 &self.pp_log
270 }
271
272 fn pp_log_mut(&mut self) -> &mut PpLog {
273 &mut self.pp_log
274 }
275}
276
277impl Sink for Mp4MuxerStreamSink {
278 fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
279 match buf {
280 MediaBuffer::Packet(packet) => self
281 .shared
282 .write_packet(self.stream_index, self.input_time_base, &packet)
283 .inspect_err(|error| pp_error!(self, "write_interleaved failed: {error}")),
284 MediaBuffer::Eos => self.finish(),
285 other => Err(Mp4MuxerError::UnsupportedBuffer(other.kind()).into()),
286 }
287 }
288
289 fn control(&mut self, msg: ControlMsg) -> Result<()> {
290 // Terminal, nothing to forward. `Stop` still contributes to this
291 // track's own "done" count — see `Mp4Muxer::open`'s own docs on
292 // why the trailer waits for every track rather than finalizing on
293 // whichever stops first.
294 if msg == ControlMsg::Stop {
295 self.finish()?;
296 }
297 Ok(())
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use crate::element::Source;
305 use crate::elements::{AudioCodec, SwAudioEncoder, SwAudioEncoderOptions};
306
307 fn open_aac_encoder(sample_rate: u32, channels: u16) -> SwAudioEncoder {
308 SwAudioEncoder::new(
309 "encoder",
310 SwAudioEncoderOptions {
311 codec: AudioCodec::Aac,
312 sample_rate,
313 channels,
314 time_base: ffmpeg::Rational::new(1, sample_rate as i32),
315 bit_rate: 64_000,
316 },
317 )
318 .expect("aac encoder must be available")
319 }
320
321 fn silent_frame(
322 sample_rate: u32,
323 channels: u16,
324 samples: usize,
325 pts: i64,
326 ) -> ffmpeg::frame::Audio {
327 let mut frame = ffmpeg::frame::Audio::new(
328 ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed),
329 samples,
330 ffmpeg::ChannelLayout::default(channels as i32),
331 );
332 frame.set_rate(sample_rate);
333 frame.set_pts(Some(pts));
334 // `frame::Audio::new` doesn't zero its buffer — leaving it
335 // uninitialized risks the encoder reading garbage bytes as NaN/Inf
336 // floats (`avcodec_send_frame` then rejects the frame outright).
337 frame.data_mut(0).fill(0);
338 frame
339 }
340
341 /// One track, driven end to end (encode -> mux -> write_trailer on
342 /// `Eos`), still produces a real, playable file — the single-track
343 /// case `Mp4Muxer` degenerates to.
344 #[test]
345 fn single_track_still_produces_a_playable_file() {
346 let mut encoder = open_aac_encoder(48000, 1);
347
348 let dir = std::env::temp_dir();
349 let path = dir.join(format!("mp4_muxer_single_test_{}.mp4", std::process::id()));
350
351 let mut muxer = Mp4Muxer::create(&path).expect("mp4 muxer must open");
352 muxer
353 .add_stream(
354 "audio",
355 encoder.parameters(),
356 ffmpeg::Rational::new(1, 48000),
357 )
358 .expect("add_stream must succeed");
359 let mut sinks = muxer.open().expect("open must write the header");
360 assert_eq!(sinks.len(), 1);
361 encoder.src_pads()[0].link(sinks.pop().unwrap());
362
363 for tick in 0..20i64 {
364 encoder
365 .consume(MediaBuffer::Audio(Arc::new(silent_frame(
366 48000,
367 1,
368 960,
369 tick * 960,
370 ))))
371 .expect("consume must succeed");
372 }
373 encoder
374 .consume(MediaBuffer::Eos)
375 .expect("eos must flush cleanly");
376 drop(encoder);
377
378 let input = ffmpeg::format::input(&path).expect("muxed file must be readable back");
379 assert_eq!(input.streams().count(), 1);
380 std::fs::remove_file(&path).ok();
381 }
382
383 /// Regression test against a leaked file handle: dropping every track
384 /// `Sink` without ever sending `Eos`/`Stop` (simulating a `Pipeline`
385 /// just getting dropped mid-recording, e.g. the process is tearing
386 /// down) must still release the underlying file — no stray clone of
387 /// the shared `Arc` (or the `ffmpeg::format::context::Output` it
388 /// guards) left holding it open. Windows won't let an open file be
389 /// deleted, so a successful `remove_file` here is direct proof
390 /// nothing lingered; on a build where that isn't already guaranteed
391 /// by construction, this would instead hang or fail with a sharing
392 /// violation.
393 #[test]
394 fn dropping_every_sink_without_eos_or_stop_still_releases_the_file() {
395 let encoder = open_aac_encoder(48000, 1);
396
397 let dir = std::env::temp_dir();
398 let path = dir.join(format!("mp4_muxer_drop_test_{}.mp4", std::process::id()));
399
400 let mut muxer = Mp4Muxer::create(&path).expect("mp4 muxer must open");
401 muxer
402 .add_stream(
403 "audio",
404 encoder.parameters(),
405 ffmpeg::Rational::new(1, 48000),
406 )
407 .expect("add_stream must succeed");
408 let sinks = muxer.open().expect("open must write the header");
409
410 // No `Eos`/`Stop`, no trailer — just drop everything, on purpose.
411 drop(sinks);
412 drop(encoder);
413
414 std::fs::remove_file(&path)
415 .expect("file handle must be released once every sink is dropped");
416 }
417
418 /// Two independent tracks (standing in for a real video+audio pair —
419 /// `Mp4Muxer` treats every stream as an opaque `codec::Parameters`, so
420 /// two AAC tracks at different sample rates exercise the same
421 /// stream-index/trailer-timing machinery a real video+audio pair
422 /// would) muxed into one file. Track `a` reaches `Eos` well before
423 /// track `b` does — proving the trailer isn't written until *both*
424 /// report done, not on whichever finishes first (which would
425 /// silently truncate whichever track was still running).
426 #[test]
427 fn muxes_two_independent_tracks_without_finalizing_early() {
428 let mut encoder_a = open_aac_encoder(48000, 2);
429 let mut encoder_b = open_aac_encoder(44100, 1);
430
431 let dir = std::env::temp_dir();
432 let path = dir.join(format!("mp4_muxer_multi_test_{}.mp4", std::process::id()));
433
434 let mut muxer = Mp4Muxer::create(&path).expect("mp4 muxer must open");
435 muxer
436 .add_stream("a", encoder_a.parameters(), ffmpeg::Rational::new(1, 48000))
437 .expect("add_stream a");
438 muxer
439 .add_stream("b", encoder_b.parameters(), ffmpeg::Rational::new(1, 44100))
440 .expect("add_stream b");
441 let mut sinks = muxer.open().expect("open must write the header");
442 assert_eq!(sinks.len(), 2);
443 let sink_b = sinks.pop().unwrap();
444 let sink_a = sinks.pop().unwrap();
445 encoder_a.src_pads()[0].link(sink_a);
446 encoder_b.src_pads()[0].link(sink_b);
447
448 for tick in 0..10i64 {
449 encoder_a
450 .consume(MediaBuffer::Audio(Arc::new(silent_frame(
451 48000,
452 2,
453 960,
454 tick * 960,
455 ))))
456 .expect("consume must succeed");
457 }
458 // Track `a` finishes here — well before track `b` has written
459 // anything at all.
460 encoder_a
461 .consume(MediaBuffer::Eos)
462 .expect("eos must flush cleanly");
463
464 for tick in 0..10i64 {
465 encoder_b
466 .consume(MediaBuffer::Audio(Arc::new(silent_frame(
467 44100,
468 1,
469 882,
470 tick * 882,
471 ))))
472 .expect("consume must succeed");
473 }
474 encoder_b
475 .consume(MediaBuffer::Eos)
476 .expect("eos must flush cleanly");
477
478 drop(encoder_a);
479 drop(encoder_b);
480
481 let mut input = ffmpeg::format::input(&path).expect("muxed file must be readable back");
482 assert_eq!(input.streams().count(), 2, "expected two tracks");
483
484 let mut counts = [0usize; 2];
485 let mut packet = ffmpeg::Packet::empty();
486 while packet.read(&mut input).is_ok() {
487 counts[packet.stream()] += 1;
488 packet = ffmpeg::Packet::empty();
489 }
490 assert!(counts[0] > 0, "track a has no packets: {counts:?}");
491 assert!(
492 counts[1] > 0,
493 "track b has no packets: {counts:?} — trailer was written before track b finished"
494 );
495 std::fs::remove_file(&path).ok();
496 }
497}